fix: エージェント切替まわりの不具合をまとめて修正(#133 の後追い) - #135
Merged
Merged
Conversation
`/agent` の切替まわりを通しでレビューして見つけた実害のあるものを直す。 - **切替で畳んだ run のイベントを畳み込まない**(`Session` の世代カウンタ `epoch`)。 `for await` は捨てたイテレータを掴んだままなので古い provider のイベントが切替後にも 届き、帰属は既に切替先。そのため遅れて来た `session_started`(Claude の `system/init` は 起動に 1〜3 秒)で**前任者の resume id が切替先の id として保存**され、以後 `codex exec resume <claude の uuid>` を投げ続けていた(`agentSessions` と state.json にも 焼き付くので往復切替でも直らない)。古いターンの `turn_completed` で completed に戻って auto-PR / 完了通知まで走る経路も同時に消える。 - **引き継ぎは「provider へ渡った」と報告されたときだけ落とす** (`AgentRunRequest.onHandoffDelivered`)。`Session` が `open()` の時点で捨てていたため、 未ログインの CLI へ切り替えて失敗 → ログインして送り直す、という普通の経路で 1 回きりの 引き継ぎが黙って消えていた(Claude は run が throw で終わるだけなのでアダプタ側の保険も 効かない)。Grok は「プロセス死で届かなかったターン」を渡した扱いにしていたのも直す。 - **Codex の argv 上限に収める**(`fitHandoff`)。会話ぶんの予算に systemPrompt (`.codiva/prompt.md` は無制限)と指示文を足すと `MAX_ARG_STRLEN` を超えうる。超えると `spawn` が E2BIG で落ち、`thread.started` が来ない = 引き継ぎが解除されないので毎ターン 落ち続けてセッションが詰む。収まらないぶんは会話の古い側から削り、1 ターンも載らなければ 引き継ぎを諦めて `notice` を 1 行残す。 - **`stripHandoff` は最後の境目で切る**。会話ログに境目と同じ見出しが混ざると引き継ぎの 残骸がユーザー発言として復元され、`lastUserInstruction` 経由で次の切替に入れ子で写る。 - **切替直後の `Ctrl+C` を効かせる**。畳んだループの `finally` が新しいエージェントを 起こす予約を持っているので、取り消さないと切替後の最初の指示だけ中断できなかった。 - **質問の判定を `kind` に統一**(`isQuestion`)。ツール名で見ていたので Grok の `_x.ai/ask_user_question` が既定(auto)モードで自動 allow され、`answers` の無い「承諾」を 返して**質問が一度もダイアログに出ていなかった**。 - Grok: 拒否の選択肢が見つからないときは `cancelled` に倒す(`options[0]` は実データでは `allow-once` なので、拒否が実行に化ける)。未応答要求の待ち行列を接続ごとに持ち、 readLoop が異常終了したら子プロセスを畳む。プロセス死の判定は JSON-RPC の code ではなく 接続のフラグで行う(`-32000` は Grok 自身も使う)。 - Codex: スレッド id が分かっているクラッシュ(panic / SIGKILL)を `failed`(終端)ではなく resumable な `connection` に倒す。
`agent_switched` は切替先が初めての provider だと `sdkSessionId` を undefined にする (そこにはまだ会話が無い)。保存条件が「現在の id があること」だったため、`/agent` で 切り替えて何も送らずに終了したセッションが **state.json から丸ごと消えていた** — 戻るための id(`agentSessions.claude`)は残っているのに、worktree だけが孤児になり タイトル・コスト・PR 参照・TODO も失われる。しかも `persistRelevantChanged` は `agent` / `agentSessions` / `sdkSessionId` の変化で即保存するので、切替した時点で消える。 条件を「**どこかに** resume 用の id がある」に変え、読み込み側(`toPersistedSessionJson`)も 同じ条件で受理する。`PersistedSession.sdkSessionId` は optional になったので、トランスクリプト 復元はそれがあるときだけ試みる(控えの id は別 provider のものなので代わりに読まない)。
- Codex: `reasoning` / `agent_message` は `parseCodexEvent` が `text.trim()` として無条件に 読むので、`text` の型まで見る(欠けた行を通すと TypeError がアダプタの generator を 突き抜けてターンのストリームごと死ぬ → `codex exec` が孤児として残る)。 `command_execution.command` も表示に使うので文字列であることを要求する (`"$ undefined"` というログ行を出さない)。 - Grok: `tool_call` / `tool_call_update` の `content` は `toolOutputText` が for-of で回すので 「オブジェクトの配列」まで見る。`plan` の各要素も `e.content` / `e.status` として無条件に 読むので要素の型を見る。 - `GROK_HOME=""` のとき `auth.json` を**相対パス**で読んでいた(リポジトリに同名のファイルが あるだけでログイン済みと誤判定する)。`codexHome()` と同じく空文字は未設定として扱う。
一覧の行のセルはどれも `wrap="truncate-end"` を持っているが `ProgressBadge` だけ持って いなかった。英語の `Awaiting permission`(19 セル)は 12 セル固定の列に収まらないので **1 セッションが 2 行に折り返し**、「1 セッション = 1 行」を前提にした `rowLineAtPoint` 以降の クリックが 1 行ずつズレて(別のセッションが選ばれ、PR セルのクリックも他人の PR を開く)、 最後の行はクリック自体が捨てられていた。日本語はちょうど 12 セルに収まっていたので 気付けなかった。 - `ProgressBadge` に `truncate-end`(他のセルと同じ) - 列幅を `BADGE_COLUMN_WIDTH` として `core/layout.ts` に出し、英語の文言を 12 セル以内へ (`Awaiting permission` → `Permission` / `Login required` → `Login needed`)。 番人として `i18n.spec.ts` に「バッジは一覧の列幅に収まる」を追加 - 準備中(worktree 作成中)のセッションに既定エージェントを載せる。無いと `sessionAgentId` の既定(`'claude'`)で表示されるので、既定が Codex / Grok のとき 「準備中 / Claude」と嘘の名前が出て、他の行と食い違うために**エージェント列が一瞬現れて 一覧全体が組み直される** - 詳細の `/agent` で同じエージェントを選び直したときにエラーを出さない(カーソルは今の エージェントから始まるので `/agent` → Enter が最も打ちやすい操作) - `/login` の未対応メッセージにエージェント名を差し込む(空だと先頭に空白が残るだけ) - `/agent` のヒントの全角区切りを `theme.ts` の記号へ(英語 UI に `・` が混ざっていた) - ログインダイアログ表示中のフッタヒントを `m.login.help` に(Esc しか効かないのに 「Enter: 送信 / Tab: 一覧」と案内していた) - ログインダイアログのアンマウントでログインプロセスを畳む(開いたまま codiva を終了すると `<cli> login --device-auth` が孤児として残り、自前のタイムアウトまでポーリングし続ける)
実装に合わせてドキュメントを更新する。 - `docs/ARCHITECTURE.md`: 畳んだ run のイベントを畳み込まない理由(resume id の混線・ 古いターンの完了で auto-PR)、引き継ぎを落とす条件、Codex の argv 予算、 「積み残しを移せるのは誰にも渡していないぶんだけ」という Claude の制約 - `.claude/rules/session-domain.md`: 世代ガード / 保存条件(どこかに resume 用の id) - `.claude/rules/sdk-integration.md`: `onHandoffDelivered` の契約、`fitHandoff`、 質問の判定をツール名でやらない - `.claude/rules/ink-components.md`: 一覧のセルは必ず truncate-end / 固定幅列の文言は カタログ側でも幅に収める - `docs/TASKS.md`: Phase D の後追い修正を一覧に
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
マージ済みの #133(エージェント切替時の会話引き継ぎ)を起点に、現在の main を
切替まわり・provider アダプタ・一覧の描画の 4 系統でレビューし、実害のあるものを直した。
#133 自体の設計(
AgentRunOptions.handoffで最初のユーザープロンプトに前置する)は妥当で、ここで直しているのはその周辺の取りこぼしと、Phase D で入った切替機能そのものの穴。
重い順
1. 切替で畳んだ run のイベントを畳み込んでいた(resume id が混線する)
setAgent()は入力キューを閉じてthis.runを捨てるが、for awaitはそのイテレータを掴んだままなので古い provider のイベントは切替後にも届く。一方
attributionはもう切替先。session_started(Claude のsystem/initは起動に 1〜3 秒かかるので、その間に/agentを押せる)で 前任者の resume id が切替先の id として保存され、以後codex exec resume <claude の uuid>を投げ続ける。agentSessionsとstate.jsonにも焼き付くので往復切替でも直らず、本物の Claude の id は失われる
turn_completedで切替済みのセッションがcompletedに戻り、auto-PR と完了通知まで走る(
run.interrupt()は best-effort なのでこの競走は普通に負ける)→ 世代カウンタ(
epoch)を持ち、世代が変わった run のイベントは畳まずにbreak(=
run.return()で古いストリームも閉じる)。catchの失敗記録と「最後の砦」も同じガードの内側へ。2. 未使用の provider へ切り替えたセッションが
state.jsonから消えるagent_switchedは切替先が初めての provider だとsdkSessionIdを undefined にする。保存条件が「現在の id があること」だったため、
/agentで切り替えて何も送らずに終了したセッションが state.json から丸ごと消えていた(戻るための
agentSessions.claudeは残っているのに worktree だけが孤児になり、タイトル・コスト・PR 参照・TODO も失われる)。しかも
persistRelevantChangedはagent/agentSessions/sdkSessionIdの変化で即保存するので、切替した時点で消える。
→ 条件を「どこかに resume 用の id がある」へ。読み込み側も同条件で受理し、
PersistedSession.sdkSessionIdを optional に。3. Grok の質問が既定モードで自動 allow されていた(ダイアログが一度も出ない)
質問の判定が ツール名(
AskUserQuestion)だったため、Grok の_x.ai/ask_user_questionはauto(既定)で自動 allow。answersの無い allow は provider には「ユーザーは答えなかった」と同じなので、質問がダイアログに出ないまま「承諾した」と返っていた。
→ 判定を
kind: 'question'(アダプタが正規化した種別)に統一(isQuestionの 1 箇所)。4. 引き継ぎが「渡る前」に使い切られていた
Sessionがopen()の時点で引き継ぎを捨てていたので、未ログインの CLI へ切り替えて失敗 →ログインして送り直すという普通の経路で 1 回きりの引き継ぎが黙って消えていた(Claude は run が
throw して終わるだけなので、アダプタ側で「渡るまで持つ」保険も効かない)。
→
AgentRunRequest.onHandoffDelivered()を新設し、渡ったと報告されるまでSessionが持つ。報告点は Claude = プロンプトを
yieldした後 / Codex =thread.started/ Grok =session/promptの応答が合成エラーでないとき(プロセス死で届かなかったターンを渡した扱いにしない)。5. Codex の argv 上限(
E2BIG)でセッションが詰む余地MAX_HANDOFF_TRANSCRIPT_BYTESは会話ぶんの予算で、systemPrompt(.codiva/prompt.mdは無制限)とユーザーの指示文も同じ argv 1 本に載る。超えると
spawnがE2BIGで落ち、thread.startedが来ない = 引き継ぎが解除されないので以後どのターンも同じ理由で落ち続ける。→ 送る直前に
fitHandoff(handoff, 残りバイト)で会話の古い側から削る。1 ターンも載らなければ引き継ぎを諦めて
noticeを 1 行残す(黙って捨てない)。6. 一覧のバッジ列が溢れて行のクリックがズレる(英語 UI)
行のセルはどれも
wrap="truncate-end"を持つのにProgressBadgeだけ持っていなかった。英語の
Awaiting permission(19 セル)は 12 セル固定の列に入らないので1 セッションが 2 行に折り返し、「1 セッション = 1 行」前提の
rowLineAtPoint以降のクリックが 1 行ずつズレて(別セッションが選ばれ、PR セルのクリックも他人の PR を開く)、最後の行はクリック自体が
捨てられていた。日本語はちょうど 12 セルだったので気付けなかった。
→
truncate-end+ 英語文言を 12 セル以内(Permission/Login needed)+ 列幅をBADGE_COLUMN_WIDTHとしてcore/layout.tsへ +i18n.spec.tsに幅の番人を追加。7. その他
Ctrl+Cが効かない(畳んだループのfinallyが新しいエージェントを起こす予約を持っているため)→ 予約を取り消して積み残しを捨てる(Grok アダプタが立ち上げ中の
中断で指示を捨てるのと同じ考え方)
stripHandoffは最後の境目で切る(会話ログに同じ見出しが混ざると残骸がユーザー発言として復元され、
lastUserInstruction経由で次の切替に入れ子で写る)options[0](実データではallow-once)に落ちて拒否が実行に化けるのを
cancelledに倒す / 未応答要求の待ち行列を接続ごとに持つ(死んだプロセスの後片付けが次のプロセスの
initializeを失敗させ、健全なプロセスを殺していた)/readLoop が異常終了したら子を畳む(同じ worktree に 2 本目が立つ)/ プロセス死の判定を
JSON-RPC の code ではなく接続のフラグで(
-32000は Grok 自身も使う)/GROK_HOME=""でauth.jsonを相対パスで読んでいたfailed(終端 = 再開の導線が消える)ではなく resumable なconnectionへreasoning/agent_messageのtext、command_execution.command、Grok のcontent/planの要素)— 通すと TypeError がターンのストリームごと殺す
Claudeと嘘の名前が出て、エージェント列が一瞬現れて一覧が組み直される)
/agentで同じエージェントを選び直したときにエラーを出さない //loginの未対応メッセージにエージェント名を差し込む /
/agentのヒントの全角区切りをtheme.tsの記号へ /ログインダイアログ表示中のフッタヒント / ダイアログのアンマウントでログインプロセスを畳む
直していない既知の制約(ドキュメントに明記した)
Claude では切替直前に送った追加指示が古い CLI 側のキューに残る。 Claude Agent SDK は
プロンプトの
AsyncIterableを先読みするので、ターン中の追加指示はその場で CLI の stdin へ渡り、
AsyncQueue.pendingは 0 のまま(=drain()で移せない)。SDK のinterrupt()はstill_queuedとして uuid を返すが、取り消すには送信時に uuid を打ってcancel_async_messageを送る必要があり、実装していない。Codex / Grok は消費が遅延評価なので移し替えが効く。
docs/ARCHITECTURE.mdと.claude/rules/session-domain.mdに限界として書いた。テスト
追加した番人(いずれも修正前は落ちる):
session.spec.ts: 畳んだ run の遅延イベントを無視する / 渡る前に死んだ run の引き継ぎを保つ /切替直後の
Ctrl+Cで予約された再起動を取り消す / provider が何と呼んでいても質問は上げるpersistence.spec.ts: 未使用 provider へ切り替えたセッションを保存・復元できるagent-handoff.spec.ts:fitHandoffの 4 ケース / 境目が本文に混ざっても最後で切るcodex-adapter.spec.ts: argv 上限で引き継ぎを削る / クラッシュを resumable にするgrok-adapter.spec.ts: 拒否できる選択肢が無ければcancelled/ 死んだプロセスの後片付けが次のプロセスを巻き込まない
codex-events.spec.ts/grok-events.spec.ts: 受理ガードの追加ケースi18n.spec.ts: バッジは一覧の列幅(12 セル)に収まるCI と同じ 4 点をローカルで通した:
lint(biome、268 ファイル)typechecktest— 118 files / 3021 tests passedbuild体感確認(実際に
/agentで切り替える・英語 UI で行をクリックする)は TTY と各 CLI の認証が要るため未実施。
🤖 Generated with Claude Code